Skip to content

node:http2: encode each header block in one call, all fields or none - #41520

Draft
robobun wants to merge 10 commits into
mainfrom
robobun/7ce16bad/h2-validate-headers-before-encode
Draft

robobun wants to merge 10 commits into
mainfrom
robobun/7ce16bad/h2-validate-headers-before-encode

Conversation

@robobun

@robobun robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A node:http2 header call that fails part-way leaves its earlier fields in the HPACK encoder table, unsent. Later blocks decode against shifted entries: another stream's set-cookie, or ERR_HTTP2_ERROR Protocol error.
  • send_trailers, push_promise and request (src/runtime/api/bun/h2_frame_parser.rs) encoded field by field. A later field could fail validation or exceed the encoder's 65536 bytes.

Fix

  • lshpack_wrapper_encode_block checks every field, then encodes the block. bun_runtime has no per-field encode.
  • The three functions stage validated fields in a per-VM scratch. They encode after the last check that can refuse the block. A field enters the table only when its block is sent.
  • Verified: test/js/node/http2/node-http2-header-block.test.ts (62 rows, 59 fail on main), test/js/node/http2/, node's 256 test-http2-*.js.
  • Self-reviewed: 32 concerns raised, 28 addressed, 4 not taken (see Notes).

Background

  • HPACK keeps one dynamic table per connection. The peer mirrors each insertion. Later blocks name entries by index.
  • Node checks maxSendHeaderBlockLength on a bound for the uncompressed block. A check after the encode cannot refuse: the fields are in the table.
  • Alternatives: a size check in the walks costs one more allocation and copy per field. Clearing the table after a failure lets unsent fields enter.

Downsides

  • maxSendHeaderBlockLength counts the uncompressed bound, like node: a block that fits only when compressed is refused. A refused response resets its stream (FRAME_SIZE_ERROR).
  • Per header call: one 16 KB mi_malloc less, +1.4% instructions (28,333 against 27,947, 7-field respond()). Per VM: +72 bytes, up to 128 KiB kept. .text: +3,072 bytes.
  • Not fixed: a field over 65536 bytes still ends the session.
Notes

How a block failed after its first fields were in the table

  1. A later field fails validation and the call throws: an invalid value or name, undefined, null, a Symbol, a toString that throws. A user try/catch is not needed for this: pushStream() and a request that was queued before 'connect' catch the native throw themselves.
  2. The encoder refuses a later field, because the name plus the value is longer than 65536 bytes. The call returns. The session error (code 9) arrives from the event loop. Blocks that other streams send in the same tick go out first, with shifted indices.
  3. request() refuses the block after the field walk: an option check, the session memory limit, maxSendHeaderBlockLength. On main these checks ran after the encode.
  4. A header value's toString() makes another header call on the same session. That block went out ahead of the fields that the first call had already encoded.

Repro for the first way (bun 1.4.3, bun client and bun server):

import http2 from "node:http2";
const srv = http2.createServer();
srv.on("stream", (st, h) => {
  if (h[":path"] === "/poison") {
    try { st.additionalHeaders({ ":status": 103, "x-a": "AAAA", "x-v": "a\r\nb" }); } catch (e) { console.log("S threw", e.code); }
    st.respond({ ":status": 200, "x-after-info": "1" }); st.end("p");
  } else { st.respond({ ":status": 200, "x-clean": "clean-value", "content-type": "text/plain" }); st.end("c"); }
});
srv.listen(0, async () => {
  const s = http2.connect("http://127.0.0.1:" + srv.address().port);
  s.on("error", e => console.log("C sess", e.code));
  const get = p => new Promise(r => { const q = s.request({ ":path": p }); q.on("response", h => { delete h.date; console.log("C", p, JSON.stringify(h)); }); q.resume(); q.on("error", e => console.log("C", p, "err", e.code)); q.on("close", r); });
  await get("/clean1"); await get("/poison"); await get("/clean2"); s.close(); srv.close();
});

Before:

C /clean1 {":status":200,"x-clean":"clean-value","content-type":"text/plain"}
S threw ERR_HTTP2_INVALID_HEADER_VALUE
C /poison {":status":200,"x-after-info":"1","x-clean":"clean-value"}
C sess ERR_HTTP2_ERROR
C /clean2 err ERR_HTTP2_ERROR

After:

C /clean1 {":status":200,"x-clean":"clean-value","content-type":"text/plain"}
S threw ERR_HTTP2_INVALID_HEADER_VALUE
C /poison {":status":200,"x-after-info":"1"}
C /clean2 {":status":200,"x-clean":"clean-value","content-type":"text/plain"}

This bug has no user report. An automated fuzz run found it. The table invariant itself has precedent: #31323 fixed it on the decode side, and the HTTP/2 client of fetch stops using a connection after a failed encode (encoder_poisoned).

What a refused block does now

  • A field over 65536 bytes in respond(), additionalHeaders(), pushStream(), request(), compat writeHead() or writeEarlyHints(): the session still ends with ERR_HTTP2_SESSION_ERROR: Session closed with error code 9 one tick later, as before. Blocks that other streams send in that tick now decode right. Node's encoder does not have this limit, so the rows that expect code 9 pin a known difference.
  • A field over 65536 bytes in sendTrailers(): frameError, the stream ends with FRAME_SIZE_ERROR, then a graceful GOAWAY, as before.
  • maxSendHeaderBlockLength: the check uses nghttp2's bound (12, plus 12 per field, plus the name and value bytes, plus 5) and runs before the encode.
    • With a limit of 300 and respond({ ":status": 200, "x-a": <N bytes> }): N up to 246 is sent by main, by this PR and by node v26.3.0. N from 247 to 465: main sends the response, this PR and node reset the stream. N from 466: main sends nothing and the request hangs, this PR and node reset the stream.
    • A refused server block dispatches frameError and resets the stream with FRAME_SIZE_ERROR. Without the reset, the 219 lengths from 247 to 465 go from a 200 to no answer.
    • additionalHeaders() marks its block with a new argument. A refused block of that call leaves the stream open, so a respond() still goes out. node:http2: refuse a header block over the default send limit, like node #43474 has the same argument.
    • The client side reacts as on main: frameError, then the stream fails with REFUSED_STREAM.
  • A throw from ClientHttp2Session.request() is no longer reported a second time as a session 'error'. Nothing reached the wire, and node reports the throw only. When the failed call also closed the session, the session is now destroyed without that error (GOAWAY code 0, it was code 2).

Measurements

Release builds of main (bbdc5a5) and of this PR's source on that base (b9d682a), linux x64, same toolchain. The container has no perf, valgrind, strace, ltrace or bloaty. The counts come from gdb: breakpoints on the allocator entry points and single steps, counted only inside the host function, in steady state.

  • Allocator calls per call (mi_malloc calls, bytes requested):
    • respond(), 7 fields: 19 (17,136 B) on main, 18 (752 B) on the PR
    • client request(): 27 (17,328 B), then 26 (944 B)
    • sendTrailers(), 2 fields: 7 (16,688 B), then 6 (304 B)
    • pushStream(), 5 fields: 15 and 1 mi_realloc, then 14 and 0
    • additionalHeaders(): 9, then 8
    • The first header call in a VM allocates the scratch. Later calls reuse it.
  • Instructions per call, callees included (a second run of each differs by less than 0.4%):
    • respond(), 7 fields: 27,947, then 28,333 (+1.4%)
    • client request(): 33,361, then 33,748 (+1.2%)
    • sendTrailers(): 14,183, then 14,105 (-0.5%)
    • pushStream(): 21,602, then 21,522 (-0.4%)
  • Copies: one copy of each name and value on both builds. Main also zero-fills name + value + 32 bytes per field before it encodes.
  • Code size of the three host functions and their helpers: 26,028 B, then 27,537 B (+1,509 B). .text: 65,382,485 B, then 65,385,557 B (+3,072 B). The stripped binary: 88,864,328 B, then 88,868,424 B (+4,096 B, one page).
  • Stack frames of the three host functions: 4,664, 4,552 and 4,640 B, then 4,800, 4,600 and 4,672 B.
  • Host functions: 29 on both. lshpack_wrapper_* symbols: 2, then 3.
  • size_of::<H2HeaderScratch>() is 72, so RareData grows by 72 B. H2FrameParser has no new field (1,496 B). The slot keeps a scratch only when each of its two buffers is at most 64 KiB.
  • Wall clock, measured on an earlier build of this PR: user CPU for 200,000 request and response pairs, 10 interleaved runs, median 10.995 s on main and 11.013 s on the PR (+0.16%). The noise floor is 1.65%, so this shows no difference.

Tests

test/js/node/http2/node-http2-header-block.test.ts is new and has 62 rows. 59 fail on bun 1.4.3-canary.1. The rows have a file of their own because they share no helper with node-http2.test.js, which has 7,700 lines.

  • "a header call that throws leaves the HPACK encoder in sync with the peer" (42 rows). Seven kinds of bad field across additionalHeaders, respond, compat res.end, server and client sendTrailers, pushStream and client request. Each row makes a clean request, the failing call, and a clean request on the same session. One row queues the failing request before 'connect'. One row closes the session from inside the failing request(). That row fails when the new line in the catch of request() is deleted. The invalid-name rows of respond() and of client request() pass on main. Two kinds (undefined, null) are values that node accepts. node:http2: normalize outbound header objects like node #41614 changes those.
  • "a header call made from a header value's toString() during another header call" (7 rows). Four server calls whose value hook answers another stream, and three client shapes with sendTrailers().
  • "a block refused after its fields are read does not reach the HPACK table" (4 rows): the client's maxSendHeaderBlockLength (the follow-up requests are made in the same tick), the client's maxSessionMemory, an option that is out of range, and a respond() whose options getter throws.
  • "a header block the encoder refuses does not reach the HPACK table" (8 rows). Six server calls send a fresh field and then a field of 65537 bytes. Three held streams get their answers in the same tick and must decode right. One row does the same with a client request(). One row reads raw frames: a field of exactly 65536 bytes is sent, and the next block is indexed against it. That row passes on main. The sessions raise maxSendHeaderBlockLength, as grpc-js does, so the rows keep their meaning when a default send limit lands.
  • "maxSendHeaderBlockLength on a server response" (1 row). With a limit of 300, a 246-byte value is sent, a 247-byte value resets the stream, and a refused additionalHeaders() block leaves the stream open. Node v26.3.0 gives the same result for the same script.

Also run on a debug build of the PR: all of test/js/node/http2/ (658 pass, 6 skip with --timeout 120000) and node's 256 test-http2-*.js files.

Not in this PR

Related PRs and landing order

Self-review

32 concerns raised, 28 addressed in the code, the tests or this text. Not taken:

  1. Remove the server reset and keep main's reaction. Without a reset, the lengths 247 to 465 above go from a 200 to no answer.
  2. Add node's close of the stream and the session after a frame error. That is the subject of node:http2: reset the stream when respond() exceeds maxSendHeaderBlockLength #43440.
  3. Apply the peer's SETTINGS_HEADER_TABLE_SIZE here. That is the subject of node:http2: apply SETTINGS_HEADER_TABLE_SIZE to the HPACK encoder and decoder #43345.
  4. Take the stream id after the header walk. That is the subject of node:http2: coerce outbound header values before taking a stream id #37568.

Rows for respondWithFile() and for a raw header array that throws were suggested and are not added.

History

The first version of this PR (September, 90f334a) fixed the validation case only. Its text said that an encoder failure needs no pre-check, because it ends the session. That statement was not tested and it is wrong: see the second way above. A later version (57fa470) also changed cork() and write(). Those changes are out again, see "Not in this PR".


[human-review] gate passed · iteration 0 · 3 files touched

fails on main (without fix)
ASAN without fix: 33 failed, 6 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/node/http2/node-http2.test.js"
bun test v1.4.3 (f42e98025)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > should be able to send a GET request [1273.26ms]
(pass) node none > Client Basics > should be able to send a POST request [900.63ms]
(pass) node none > Client Basics > constants [23.25ms]
(pass) node none > Client Basics > getDefaultSettings [8.90ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [22.36ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [7.54ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [4.55ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [6.35ms]
(pass) node none > Client Basics > should be able to send data using end [932.65ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [922.14ms]
(pass) node none > Client Basics > http2 should receive remoteSettings when receiving 
... (truncated)

release without fix: 33 failed, 6 skipped
bun test v1.4.3-canary.1 (f42e98025)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > constants [1.08ms]
(pass) node none > Client Basics > getDefaultSettings [0.26ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [0.37ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [0.15ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [0.04ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [0.09ms]
(pass) node none > Client Basics > is possible to abort request [2.66ms]
(pass) node none > Client Basics > aborted event should work with abortController [1.08ms]
(pass) node none > Client Basics > aborted event should work with aborted signal [0.82ms]
(pass) node none > Client Basics > signal validation matches node: non-signal objects throw, duck-typed { aborted } is accepted [1.32ms]
(pass) node none > Client Basics > should fail to connect over HTTP/1.1 [44.37ms]
(skip) node none > Client Basics > should not leak memory
(pass) node none > Client Basics > headers cannot be bigge
... (truncated)
passes on PR (with fix)
ASAN with fix: 6 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/node/http2/node-http2.test.js"
bun test v1.4.3 (f42e98025)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > should be able to send a GET request [1373.71ms]
(pass) node none > Client Basics > should be able to send a POST request [859.40ms]
(pass) node none > Client Basics > constants [28.66ms]
(pass) node none > Client Basics > getDefaultSettings [12.80ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [26.12ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [8.67ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [6.50ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [8.74ms]
(pass) node none > Client Basics > should be able to send data using end [910.71ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [905.16ms]
(pass) node none > Client Basics > http2 should receive remoteSettings when receiving
... (truncated)

release with fix: 6 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     f7dc184a46
  features     baseline

23 deps, 131 codegen, 1172 objects in 1269ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1244] gen bindgenv2
[2/1244] gen ErrorCode+*.h
[3/1244] fetch zlib
[zlib] up to date
[4/1244] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[5/1217] fetch tinycc
[tinycc] up to date
[6/1216] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[7/1216] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[8/1216] gen .bind.ts → GeneratedBindings.cpp
[9/1216] gen ProcessBindingBuffer.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingBuffer.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingBuffer.cpp
[10/1216] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[11/1216] install /workspace/bu
... (truncated)
diff hotspot
src/js/node/http2.ts                   |  15 +-
 src/runtime/api/bun/h2_frame_parser.rs | 362 +++++++++++++++------------------
 test/js/node/http2/node-http2.test.js  | 323 +++++++++++++++++++++++++++++
 3 files changed, 487 insertions(+), 213 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                    reads  edits  tests
src/js/node/http2.ts                        2      2     31
src/runtime/api/bun/h2_frame_parser.rs      9     18     32
test/js/node/http2/node-http2.test.js       1      0     31

root cause · written by the author bot

The header-serialization paths for request, sendTrailers, additionalHeaders, and pushStream validated and HPACK-encoded each field one at a time, so when a later field failed validation the call threw after earlier fields had already been inserted into the connection's shared dynamic table without ever being sent, leaving the peer's decoder out of sync and causing later header blocks to decode onto the wrong stream or trigger a COMPRESSION_ERROR GOAWAY. The fix collects and validates the complete header list into a private buffer before the encoder is touched, then performs a single…

@robobun

robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced on bun 1.4.3-canary.1. A header call leaves fields in the HPACK encoder table that the peer never receives, in four ways:

  1. The call throws on a later field (the script in the PR body): the /poison response carries x-clean from stream 1, then the session dies with ERR_HTTP2_ERROR.
  2. A field is over 65536 bytes, for example stream.respond({ ":status": 200, "x-fresh": "v", "x-big": <65537 bytes> }): three responses that other streams send in the same tick arrive with wrong headers and no error.
  3. request() refuses the block after it read the fields (an option, the session memory limit, maxSendHeaderBlockLength).
  4. A header value's toString() makes another header call on the same session.

With this branch (41545e3) all four decode right. The 62 rows of the new test/js/node/http2/node-http2-header-block.test.ts cover them: 59 fail on 1.4.3-canary.1, all pass on the branch.

Status: the branch is restructured after a self-review. The cork() and write() changes of the previous version are out (see "Not in this PR" in the PR body). Open question for a maintainer: the landing order against #43440, #43474, #43619, #43632 and #43523, which change the same three functions.

@robobun

robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:23 AM PT - Oct 9th, 2026

✅ @robobun, your commit 41545e3578e00fb7126ac3b78da0e8251b393dab passed in Build #124090! 🎉


🧪   To try this PR locally:

bunx bun-pr 41520

That installs a local version of the PR into your bun-41520 executable, so you can run:

bun-41520 --bun

@coderabbitai

coderabbitai Bot commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: aee6d4e2-fe18-489e-ac4f-486b5f012989


📥 Commits

Reviewing files that changed from the base of the PR and between 16eb85a and 9c3ca13.


📒 Files selected for processing (3)
  • src/js/node/http2.ts
  • src/runtime/api/bun/h2_frame_parser.rs
  • test/js/node/http2/node-http2.test.js

Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.



Walkthrough

HTTP/2 header serialization now validates fields before HPACK mutation across request, trailer, and PUSH_PROMISE paths. Request size checks and native request error handling were updated. Regression tests cover invalid headers and session continuity.

Changes

HTTP/2 header encoding

Layer / File(s) Summary
Header collection and batch encoding
src/runtime/api/bun/h2_frame_parser.rs
Header fields are copied into a private HeaderList, validated, and encoded in one HPACK operation.
Trailer and PUSH_PROMISE integration
src/runtime/api/bun/h2_frame_parser.rs
Trailer and PUSH_PROMISE fields are collected before encoding. Allocation, compression, frame-size, and session-error handling remain covered.
Request encoding and session errors
src/runtime/api/bun/h2_frame_parser.rs, src/js/node/http2.ts
Request headers use deferred encoding and pre-compression size checks. Native request failures rethrow without scheduling a session error.
Header validation regression coverage
test/js/node/http2/node-http2.test.js
Tests cover invalid response, trailer, push-stream, and client-request headers, including later successful operations and session stability.

Suggested reviewers: jarred-sumner, cirospaciari

Merge Risk: ⚪ Minimal · up to 9c3ca

HTTP/2 header handling now validates complete header lists before HPACK encoding, preventing invalid fields from corrupting later connection activity. No concrete current-head merge-blocking risk remains.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Title check Passed The title clearly and concisely describes the main change: encoding each HTTP/2 header block atomically so all fields succeed or none are applied.
Description check Passed The description is detailed and covers the problem, implementation, scope, limitations, and verification results. It does not use the exact template headings, but it provides the required information …



Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 src/js/node/http2.ts — nit: removing the process.nextTick(emitErrorNT, ...) call in ClientHttp2Session#request()'s catch block leaves emitErrorNT (line 447) with no callers in this file — it is now dead code. Fix: delete the emitErrorNT function in the same PR (REVIEW.md: "Delete dead code in the same PR that makes it dead… helpers whose last caller you rewired").

    Extended reasoning...

    Grep of emitErrorNT in src/js/node/http2.ts after the diff returns only line 447 (the definition); the sole call site at the old line 6269 was removed by this change. The identically-named helper in src/js/internal/streams/destroy.ts is a separate module-local function with a different signature and does not reference this one. Nothing else in the module (or the codebase) imports or calls http2.ts's three-argument emitErrorNT, so the function is unreachable after merge. REVIEW.md lists dead-code deletion as required scope for the PR that orphans the helper.

    Verification: nit — The diff removes the sole call site: - process.nextTick(emitErrorNT, this, e, this.#connections === 0 && this.#closed); at src/js/node/http2.ts:6269 (base). After the change, grep of emitErrorNT in src/js/node/http2.ts returns only line 447, the definition function emitErrorNT(self: any, error: any, destroy: boolean) { ... }. This is a module-local (non-exported) function with…

Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
@robobun

robobun commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

Both review findings are addressed in 0bffe36: HeaderField stores its lengths as usize, so there is no .expect() on a user-reachable path, and the now unused emitErrorNT helper is deleted.

Also ran node's test-http2-*.js files (256 of them) against this branch with the debug build: 256 pass, 0 fail.

Comment thread src/js/node/http2.ts Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

@robobun

robobun commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

#41614 is stacked on this branch. It adds node's value semantics (undefined skipped, null stringified, symbol keys never on the wire) on top of the HeaderList change here, and removes the "null value" row from the poison matrix because null is no longer a throw.

@robobun
robobun force-pushed the robobun/7ce16bad/h2-validate-headers-before-encode branch from f7dc184 to 90f334a Compare September 6, 2026 12:15

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

The cork buffer is shared by every session on the thread. cork() flushes the
session that owns it through that session's transport. When the transport is
a JS Duplex, its _write can make a header call on the session that is taking
the buffer over. cork() then replaced the owner and reset the offset, so the
frames of that call were dropped while their fields stayed in the HPACK
table. A block that was encoded before the hand-over also went out behind a
block that was encoded after it.

cork() now reads the slot again after each forced uncork and keeps what a
re-entrant call corked. The header encode takes the cork before it encodes,
so no JS runs between the encode and the last byte of the block. A native
socket on top of a JS Duplex goes through the unit-assembling write, like a
session with no native socket, so a header block larger than the cork is
handed over whole.
The HPACK encoder refuses a field whose name plus value is over 65536
bytes. The walks encoded field by field, so a block with such a field
left its earlier fields in the dynamic table. The call does not throw:
the session error arrives from the event loop, and header blocks that
other streams wrote in the same tick went out with shifted indices.

lshpack_wrapper_encode_block checks every field of a block before the
first one reaches the encoder, then encodes the staged bytes in place.
HPACK::encode_block is the only encode the runtime crate can call:
HPACK::encode is crate-private to bun_http now. The walks stage into a
per-VM scratch (RareData), so a header call makes no allocation once the
scratch is warm. The h2 engine stages and encodes the same way.

request() checks maxSendHeaderBlockLength on the pre-compression bound.
A refused server response now resets its stream with FRAME_SIZE_ERROR,
as node does, and a refused 1xx block leaves the stream open. Before,
the peer got no frame for it.
@robobun
robobun force-pushed the robobun/7ce16bad/h2-validate-headers-before-encode branch from 90f334a to 57fa470 Compare October 7, 2026 22:38
Comment thread src/http/lshpack.rs Outdated
Comment thread src/http/lshpack.rs Outdated
Comment thread src/jsc/bindings/c-bindings.cpp Outdated
Comment thread src/jsc/rare_data.rs Outdated
Comment thread src/runtime/api/bun/h2/connection.rs Outdated
Comment thread src/runtime/api/bun/h2/connection.rs Outdated
Comment thread src/runtime/api/bun/h2/connection.rs Outdated
Comment thread src/runtime/api/bun/h2/hpack.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
…s header block senders

Shorten the comments the header block encode added.
…test

node closes the session after a frame error, so the 1xx case gets a session of
its own, and the test no longer asks for a later request on the same session.
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
clippy::chunks_exact_to_as_chunks rejects chunks_exact with a constant size.
@robobun robobun changed the title node:http2: validate the whole header list before the HPACK encoder sees it node:http2: encode each header block in one call, all fields or none Oct 8, 2026
Take the cork() hand-over loop, the cork before the encode and the write()
routing for Duplex-backed sockets out again. cork() and write() are as on
main.

additionalHeaders() marks its block as informational with an argument. A
refused block of that call leaves the stream open for respond(). Every other
refused server block resets its stream. The :status check is gone.

The engine's send_header_block() and send_push_promise() take the
HeaderBlock. Connection keeps no staged block, so H2FrameParser has the size
it has on main.

Tests: remove the cork hand-over rows. Add rows for a header call made from a
header value's toString(), for a block refused after the field walk, for a
request queued before connect, and for a session closed inside a failed
request(). The encoder refusal rows raise maxSendHeaderBlockLength.
Comment thread src/runtime/api/bun/h2/connection.rs Outdated
…k.test.ts

node-http2.test.js is as on main again. The new file is TypeScript and
type-checks under test/tsconfig.json.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants